/**
 * Copyright 2010-present Facebook.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.facebook;

import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.support.v4.content.LocalBroadcastManager;
import android.util.Log;
import com.facebook.internal.AttributionIdentifiers;
import com.facebook.internal.Logger;
import com.facebook.internal.Utility;
import com.facebook.internal.Validate;
import com.facebook.model.GraphObject;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.io.*;
import java.math.BigDecimal;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;


/**
 * <p>
 * The AppEventsLogger class allows the developer to log various types of events back to Facebook.  In order to log
 * events, the app must create an instance of this class via a {@link #newLogger newLogger} method, and then call
 * the various "log" methods off of that.
 * </p>
 * <p>
 * This client-side event logging is then available through Facebook App Insights
 * and for use with Facebook Ads conversion tracking and optimization.
 * </p>
 * <p/>
 * The AppEventsLogger class has a few related roles:
 * <ul>
 * <li>
 * Logging predefined and application-defined events to Facebook App Insights with a
 * numeric value to sum across a large number of events, and an optional set of key/value
 * parameters that define "segments" for this event (e.g., 'purchaserStatus' : 'frequent', or
 * 'gamerLevel' : 'intermediate').  These events may also be used for ads conversion tracking,
 * optimization, and other ads related targeting in the future.
 * </li>
 * <li>
 * Methods that control the way in which events are flushed out to the Facebook servers.
 * </li>
 * </ul>
 * Here are some important characteristics of the logging mechanism provided by AppEventsLogger:
 * <ul>
 * <li>
 * Events are not sent immediately when logged.  They're cached and flushed out to the Facebook servers
 * in a number of situations:
 * <ul>
 * <li>when an event count threshold is passed (currently 100 logged events).</li>
 * <li>when a time threshold is passed (currently 60 seconds).</li>
 * <li>when an app has gone to background and is then brought back to the foreground.</li>
 * </ul>
 * <li>
 * Events will be accumulated when the app is in a disconnected state, and sent when the connection is
 * restored and one of the above 'flush' conditions are met.
 * </li>
 * <li>
 * The AppEventsLogger class is intended to be used from the thread it was created on.  Multiple AppEventsLoggers
 * may be created on other threads if desired.
 * </li>
 * <li>
 * The developer can call the setFlushBehavior method to force the flushing of events to only
 * occur on an explicit call to the `flush` method.
 * </li>
 * <li>
 * The developer can turn on console debug output for event logging and flushing to the server
 * Settings.addLoggingBehavior(LoggingBehavior.APP_EVENTS);
 * </li>
 * </ul>
 * Some things to note when logging events:
 * <ul>
 * <li>
 * There is a limit on the number of unique event names an app can use, on the order of 300.
 * </li>
 * <li>
 * There is a limit to the number of unique parameter names in the provided parameters that can
 * be used per event, on the order of 25.  This is not just for an individual call, but for all
 * invocations for that eventName.
 * </li>
 * <li>
 * Event names and parameter names (the keys in the NSDictionary) must be between 2 and 40 characters, and
 * must consist of alphanumeric characters, _, -, or spaces.
 * </li>
 * <li>
 * The length of each parameter value can be no more than on the order of 100 characters.
 * </li>
 * </ul>
 */
public class AppEventsLogger
{
  // Enums

  /**
   * Controls when an AppEventsLogger sends log events to the server
   */
  public enum FlushBehavior
  {
    /**
     * Flush automatically: periodically (once a minute or after every 100 events), and always at app reactivation.
     * This is the default value.
     */
    AUTO,

    /**
     * Only flush when AppEventsLogger.flush() is explicitly invoked.
     */
    EXPLICIT_ONLY,
  }

  private enum SuppressionTimeoutBehavior
  {
    // Successfully logging an event will reset the timeout period (i.e., events will log no more than every N
    // seconds).
    RESET_TIMEOUT_WHEN_LOG_SUCCESSFUL,
    // Attempting to log an event, even if it is suppressed, will reset the timeout period (i.e., events will not
    // be logged until they have been "silent" for at least N seconds).
    RESET_TIMEOUT_WHEN_LOG_ATTEMPTED,
  }

  private static class EventSuppression
  {
    // Timeout period in seconds
    private int timeoutPeriod;
    private SuppressionTimeoutBehavior behavior;

    EventSuppression(int timeoutPeriod, SuppressionTimeoutBehavior behavior)
    {
      this.timeoutPeriod = timeoutPeriod;
      this.behavior = behavior;
    }

    int getTimeoutPeriod()
    {
      return timeoutPeriod;
    }

    SuppressionTimeoutBehavior getBehavior()
    {
      return behavior;
    }
  }

  // Constants
  private static final String TAG = AppEventsLogger.class.getCanonicalName();

  private static final int NUM_LOG_EVENTS_TO_TRY_TO_FLUSH_AFTER = 100;
  private static final int FLUSH_PERIOD_IN_SECONDS = 60;
  private static final int APP_SUPPORTS_ATTRIBUTION_ID_RECHECK_PERIOD_IN_SECONDS = 60 * 60 * 24;
  private static final int APP_ACTIVATE_SUPPRESSION_PERIOD_IN_SECONDS = 5 * 60;

  // Instance member variables
  private final Context context;
  private final AccessTokenAppIdPair accessTokenAppId;

  private static Map<AccessTokenAppIdPair, SessionEventsState> stateMap =
      new ConcurrentHashMap<AccessTokenAppIdPair, SessionEventsState>();
  private static Timer flushTimer;
  private static Timer supportsAttributionRecheckTimer;
  private static FlushBehavior flushBehavior = FlushBehavior.AUTO;
  private static boolean requestInFlight;
  private static Context applicationContext;
  private static Object staticLock = new Object();
  private static String hashedDeviceAndAppId;
  private static Map<String, Date> mapEventsToSuppressionTime = new HashMap<String, Date>();
  @SuppressWarnings("serial")
  private static Map<String, EventSuppression> mapEventNameToSuppress = new HashMap<String, EventSuppression>()
  {
    {
      put(AppEventsConstants.EVENT_NAME_ACTIVATED_APP,
          new EventSuppression(APP_ACTIVATE_SUPPRESSION_PERIOD_IN_SECONDS,
              SuppressionTimeoutBehavior.RESET_TIMEOUT_WHEN_LOG_ATTEMPTED));
    }
  };

  // Rather than retaining Sessions, we extract the information we need and track app events by
  // application ID and access token (which may be null for Session-less calls). This avoids needing to
  // worry about Session lifecycle and also allows us to coalesce app events from different Sessions
  // that have the same access token/app ID.
  private static class AccessTokenAppIdPair implements Serializable
  {
    private static final long serialVersionUID = 1L;
    private final String accessToken;
    private final String applicationId;

    AccessTokenAppIdPair(Session session)
    {
      this(session.getAccessToken(), session.getApplicationId());
    }

    AccessTokenAppIdPair(String accessToken, String applicationId)
    {
      this.accessToken = Utility.isNullOrEmpty(accessToken) ? null : accessToken;
      this.applicationId = applicationId;
    }

    String getAccessToken()
    {
      return accessToken;
    }

    String getApplicationId()
    {
      return applicationId;
    }

    @Override
    public int hashCode()
    {
      return (accessToken == null ? 0 : accessToken.hashCode()) ^
          (applicationId == null ? 0 : applicationId.hashCode());
    }

    @Override
    public boolean equals(Object o)
    {
      if (!(o instanceof AccessTokenAppIdPair))
      {
        return false;
      }
      AccessTokenAppIdPair p = (AccessTokenAppIdPair) o;
      return Utility.areObjectsEqual(p.accessToken, accessToken) &&
          Utility.areObjectsEqual(p.applicationId, applicationId);
    }

    private static class SerializationProxyV1 implements Serializable
    {
      private static final long serialVersionUID = -2488473066578201069L;
      private final String accessToken;
      private final String appId;

      private SerializationProxyV1(String accessToken, String appId)
      {
        this.accessToken = accessToken;
        this.appId = appId;
      }

      private Object readResolve()
      {
        return new AccessTokenAppIdPair(accessToken, appId);
      }
    }

    private Object writeReplace()
    {
      return new SerializationProxyV1(accessToken, applicationId);
    }
  }

  /**
   * This method is deprecated.  Use {@link Settings#getLimitEventAndDataUsage(Context)} instead.
   */
  @Deprecated
  public static boolean getLimitEventUsage(Context context)
  {
    return Settings.getLimitEventAndDataUsage(context);
  }

  /**
   * This method is deprecated.  Use {@link Settings#setLimitEventAndDataUsage(Context, boolean)} instead.
   */
  @Deprecated
  public static void setLimitEventUsage(Context context, boolean limitEventUsage)
  {
    Settings.setLimitEventAndDataUsage(context, limitEventUsage);
  }

  /**
   * Notifies the events system that the app has launched & logs an activatedApp event.  Should be called whenever
   * your app becomes active, typically in the onResume() method of each long-running Activity of your app.
   * <p/>
   * Use this method if your application ID is stored in application metadata, otherwise see
   * {@link AppEventsLogger#activateApp(android.content.Context, String)}.
   *
   * @param context Used to access the applicationId and the attributionId for non-authenticated users.
   */
  public static void activateApp(Context context)
  {
    activateApp(context, Utility.getMetadataApplicationId(context));
  }

  /**
   * Notifies the events system that the app has launched & logs an activatedApp event.  Should be called whenever
   * your app becomes active, typically in the onResume() method of each long-running Activity of your app.
   *
   * @param context       Used to access the attributionId for non-authenticated users.
   * @param applicationId The specific applicationId to report the activation for.
   */
  @SuppressWarnings("deprecation")
  public static void activateApp(Context context, String applicationId)
  {
    if (context == null || applicationId == null)
    {
      throw new IllegalArgumentException("Both context and applicationId must be non-null");
    }

    // activateApp supercedes publishInstall in the public API, so we need to explicitly invoke it, since the server
    // can't reliably infer install state for all conditions of an app activate.
    Settings.publishInstallAsync(context, applicationId);

    AppEventsLogger logger = new AppEventsLogger(context, applicationId, null);
    logger.logEvent(AppEventsConstants.EVENT_NAME_ACTIVATED_APP);
  }

  /**
   * Build an AppEventsLogger instance to log events through.  The Facebook app that these events are targeted at
   * comes from this application's metadata. The application ID used to log events will be determined from
   * the app ID specified in the package metadata.
   *
   * @param context Used to access the applicationId and the attributionId for non-authenticated users.
   * @return AppEventsLogger instance to invoke log* methods on.
   */
  public static AppEventsLogger newLogger(Context context)
  {
    return new AppEventsLogger(context, null, null);
  }

  /**
   * Build an AppEventsLogger instance to log events through.
   *
   * @param context Used to access the attributionId for non-authenticated users.
   * @param session Explicitly specified Session to log events against.  If null, the activeSession
   *                will be used if it's open, otherwise the logging will happen against the default
   *                app ID specified via the app ID specified in the package metadata.
   * @return AppEventsLogger instance to invoke log* methods on.
   */
  public static AppEventsLogger newLogger(Context context, Session session)
  {
    return new AppEventsLogger(context, null, session);
  }

  /**
   * Build an AppEventsLogger instance to log events through.
   *
   * @param context       Used to access the attributionId for non-authenticated users.
   * @param applicationId Explicitly specified Facebook applicationId to log events against.  If null, the default
   *                      app ID specified in the package metadata will be used.
   * @param session       Explicitly specified Session to log events against.  If null, the activeSession
   *                      will be used if it's open, otherwise the logging will happen against the specified
   *                      app ID.
   * @return AppEventsLogger instance to invoke log* methods on.
   */
  public static AppEventsLogger newLogger(Context context, String applicationId, Session session)
  {
    return new AppEventsLogger(context, applicationId, session);
  }

  /**
   * Build an AppEventsLogger instance to log events that are attributed to the application but not to
   * any particular Session.
   *
   * @param context       Used to access the attributionId for non-authenticated users.
   * @param applicationId Explicitly specified Facebook applicationId to log events against.  If null, the default
   *                      app ID specified
   *                      in the package metadata will be used.
   * @return AppEventsLogger instance to invoke log* methods on.
   */
  public static AppEventsLogger newLogger(Context context, String applicationId)
  {
    return new AppEventsLogger(context, applicationId, null);
  }

  /**
   * The action used to indicate that a flush of app events has occurred. This should
   * be used as an action in an IntentFilter and BroadcastReceiver registered with
   * the {@link android.support.v4.content.LocalBroadcastManager}.
   */
  public static final String ACTION_APP_EVENTS_FLUSHED = "com.facebook.sdk.APP_EVENTS_FLUSHED";

  public static final String APP_EVENTS_EXTRA_NUM_EVENTS_FLUSHED = "com.facebook.sdk.APP_EVENTS_NUM_EVENTS_FLUSHED";
  public static final String APP_EVENTS_EXTRA_FLUSH_RESULT = "com.facebook.sdk.APP_EVENTS_FLUSH_RESULT";

  /**
   * Access the behavior that AppEventsLogger uses to determine when to flush logged events to the server. This
   * setting applies to all instances of AppEventsLogger.
   *
   * @return specified flush behavior.
   */
  public static FlushBehavior getFlushBehavior()
  {
    synchronized (staticLock)
    {
      return flushBehavior;
    }
  }

  /**
   * Set the behavior that this AppEventsLogger uses to determine when to flush logged events to the server. This
   * setting applies to all instances of AppEventsLogger.
   *
   * @param flushBehavior the desired behavior.
   */
  public static void setFlushBehavior(FlushBehavior flushBehavior)
  {
    synchronized (staticLock)
    {
      AppEventsLogger.flushBehavior = flushBehavior;
    }
  }

  /**
   * Log an app event with the specified name.
   *
   * @param eventName eventName used to denote the event.  Choose amongst the EVENT_NAME_* constants in
   *                  {@link AppEventsConstants} when possible.  Or create your own if none of the EVENT_NAME_*
   *                  constants are applicable.
   *                  Event names should be 40 characters or less, alphanumeric, and can include spaces, underscores
   *                  or hyphens, but mustn't have a space or hyphen as the first character.  Any given app should
   *                  have no more than ~300 distinct event names.
   */
  public void logEvent(String eventName)
  {
    logEvent(eventName, null);
  }

  /**
   * Log an app event with the specified name and the supplied value.
   *
   * @param eventName  eventName used to denote the event.  Choose amongst the EVENT_NAME_* constants in
   *                   {@link AppEventsConstants} when possible.  Or create your own if none of the EVENT_NAME_*
   *                   constants are applicable.
   *                   Event names should be 40 characters or less, alphanumeric, and can include spaces, underscores
   *                   or hyphens, but mustn't have a space or hyphen as the first character.  Any given app should
   *                   have no more than ~300 distinct event names.
   *                   * @param eventName
   * @param valueToSum a value to associate with the event which will be summed up in Insights for across all
   *                   instances of the event, so that average values can be determined, etc.
   */
  public void logEvent(String eventName, double valueToSum)
  {
    logEvent(eventName, valueToSum, null);
  }

  /**
   * Log an app event with the specified name and set of parameters.
   *
   * @param eventName  eventName used to denote the event.  Choose amongst the EVENT_NAME_* constants in
   *                   {@link AppEventsConstants} when possible.  Or create your own if none of the EVENT_NAME_*
   *                   constants are applicable.
   *                   Event names should be 40 characters or less, alphanumeric, and can include spaces, underscores
   *                   or hyphens, but mustn't have a space or hyphen as the first character.  Any given app should
   *                   have no more than ~300 distinct event names.
   * @param parameters A Bundle of parameters to log with the event.  Insights will allow looking at the logs of these
   *                   events via different parameter values.  You can log on the order of 10 parameters with each
   *                   distinct eventName.  It's advisable to keep the number of unique values provided for each
   *                   parameter in the, at most, thousands.  As an example, don't attempt to provide a unique
   *                   parameter value for each unique user in your app.  You won't get meaningful aggregate reporting
   *                   on so many parameter values.  The values in the bundles should be Strings or numeric values.
   */
  public void logEvent(String eventName, Bundle parameters)
  {
    logEvent(eventName, null, parameters, false);
  }

  /**
   * Log an app event with the specified name, supplied value, and set of parameters.
   *
   * @param eventName  eventName used to denote the event.  Choose amongst the EVENT_NAME_* constants in
   *                   {@link AppEventsConstants} when possible.  Or create your own if none of the EVENT_NAME_*
   *                   constants are applicable.
   *                   Event names should be 40 characters or less, alphanumeric, and can include spaces, underscores
   *                   or hyphens, but mustn't have a space or hyphen as the first character.  Any given app should
   *                   have no more than ~300 distinct event names.
   * @param valueToSum a value to associate with the event which will be summed up in Insights for across all
   *                   instances of the event, so that average values can be determined, etc.
   * @param parameters A Bundle of parameters to log with the event.  Insights will allow looking at the logs of these
   *                   events via different parameter values.  You can log on the order of 10 parameters with each
   *                   distinct eventName.  It's advisable to keep the number of unique values provided for each
   *                   parameter in the, at most, thousands.  As an example, don't attempt to provide a unique
   *                   parameter value for each unique user in your app.  You won't get meaningful aggregate reporting
   *                   on so many parameter values.  The values in the bundles should be Strings or numeric values.
   */
  public void logEvent(String eventName, double valueToSum, Bundle parameters)
  {
    logEvent(eventName, valueToSum, parameters, false);
  }

  /**
   * Logs a purchase event with Facebook, in the specified amount and with the specified currency.
   *
   * @param purchaseAmount Amount of purchase, in the currency specified by the 'currency' parameter. This value
   *                       will be rounded to the thousandths place (e.g., 12.34567 becomes 12.346).
   * @param currency       Currency used to specify the amount.
   */
  public void logPurchase(BigDecimal purchaseAmount, Currency currency)
  {
    logPurchase(purchaseAmount, currency, null);
  }

  /**
   * Logs a purchase event with Facebook, in the specified amount and with the specified currency.  Additional
   * detail about the purchase can be passed in through the parameters bundle.
   *
   * @param purchaseAmount Amount of purchase, in the currency specified by the 'currency' parameter. This value
   *                       will be rounded to the thousandths place (e.g., 12.34567 becomes 12.346).
   * @param currency       Currency used to specify the amount.
   * @param parameters     Arbitrary additional information for describing this event.  Should have no more than
   *                       10 entries, and keys should be mostly consistent from one purchase event to the next.
   */
  public void logPurchase(BigDecimal purchaseAmount, Currency currency, Bundle parameters)
  {

    if (purchaseAmount == null)
    {
      notifyDeveloperError("purchaseAmount cannot be null");
      return;
    }
    else
      if (currency == null)
      {
        notifyDeveloperError("currency cannot be null");
        return;
      }

    if (parameters == null)
    {
      parameters = new Bundle();
    }
    parameters.putString(AppEventsConstants.EVENT_PARAM_CURRENCY, currency.getCurrencyCode());

    logEvent(AppEventsConstants.EVENT_NAME_PURCHASED, purchaseAmount.doubleValue(), parameters);
    eagerFlush();
  }

  /**
   * Explicitly flush any stored events to the server.  Implicit flushes may happen depending on the value
   * of getFlushBehavior.  This method allows for explicit, app invoked flushing.
   */
  public void flush()
  {
    flush(FlushReason.EXPLICIT);
  }

  /**
   * Call this when the consuming Activity/Fragment receives an onStop() callback in order to persist any
   * outstanding events to disk, so they may be flushed at a later time. The next flush (explicit or not)
   * will check for any outstanding events and, if present, include them in that flush. Note that this call
   * may trigger an I/O operation on the calling thread. Explicit use of this method is not necessary
   * if the consumer is making use of {@link UiLifecycleHelper}, which will take care of making the call
   * in its own onStop() callback.
   */
  public static void onContextStop()
  {
    PersistedEvents.persistEvents(applicationContext, stateMap);
  }

  boolean isValidForSession(Session session)
  {
    AccessTokenAppIdPair other = new AccessTokenAppIdPair(session);
    return accessTokenAppId.equals(other);
  }

  /**
   * This method is intended only for internal use by the Facebook SDK and other use is unsupported.
   */
  public void logSdkEvent(String eventName, Double valueToSum, Bundle parameters)
  {
    logEvent(eventName, valueToSum, parameters, true);
  }

  /**
   * Returns the app ID this logger was configured to log to.
   *
   * @return the Facebook app ID
   */
  public String getApplicationId()
  {
    return accessTokenAppId.getApplicationId();
  }

  //
  // Private implementation
  //

  private enum FlushReason
  {
    EXPLICIT,
    TIMER,
    SESSION_CHANGE,
    PERSISTED_EVENTS,
    EVENT_THRESHOLD,
    EAGER_FLUSHING_EVENT,
  }

  private enum FlushResult
  {
    SUCCESS,
    SERVER_ERROR,
    NO_CONNECTIVITY,
    UNKNOWN_ERROR
  }

  /**
   * Constructor is private, newLogger() methods should be used to build an instance.
   */
  private AppEventsLogger(Context context, String applicationId, Session session)
  {

    Validate.notNull(context, "context");
    this.context = context;

    if (session == null)
    {
      session = Session.getActiveSession();
    }

    if (session != null)
    {
      accessTokenAppId = new AccessTokenAppIdPair(session);
    }
    else
    {
      if (applicationId == null)
      {
        applicationId = Utility.getMetadataApplicationId(context);
      }
      accessTokenAppId = new AccessTokenAppIdPair(null, applicationId);
    }

    synchronized (staticLock)
    {

      if (hashedDeviceAndAppId == null)
      {
        hashedDeviceAndAppId = Utility.getHashedDeviceAndAppID(context, applicationId);
      }

      if (applicationContext == null)
      {
        applicationContext = context.getApplicationContext();
      }
    }

    initializeTimersIfNeeded();
  }

  private static void initializeTimersIfNeeded()
  {
    synchronized (staticLock)
    {
      if (flushTimer != null)
      {
        return;
      }
      flushTimer = new Timer();
      supportsAttributionRecheckTimer = new Timer();
    }

    flushTimer.schedule(
        new TimerTask()
        {
          @Override
          public void run()
          {
            if (getFlushBehavior() != FlushBehavior.EXPLICIT_ONLY)
            {
              flushAndWait(FlushReason.TIMER);
            }
          }
        },
        0,  // start immediately
        FLUSH_PERIOD_IN_SECONDS * 1000);

    supportsAttributionRecheckTimer.schedule(
        new TimerTask()
        {
          @Override
          public void run()
          {
            Set<String> applicationIds = new HashSet<String>();
            synchronized (staticLock)
            {
              for (AccessTokenAppIdPair accessTokenAppId : stateMap.keySet())
              {
                applicationIds.add(accessTokenAppId.getApplicationId());
              }
            }
            for (String applicationId : applicationIds)
            {
              Utility.queryAppSettings(applicationId, true);
            }
          }
        },
        0,   // start immediately
        APP_SUPPORTS_ATTRIBUTION_ID_RECHECK_PERIOD_IN_SECONDS * 1000);
  }

  private void logEvent(String eventName, Double valueToSum, Bundle parameters, boolean isImplicitlyLogged)
  {

    AppEvent event = new AppEvent(eventName, valueToSum, parameters, isImplicitlyLogged);
    logEvent(context, event, accessTokenAppId);
  }

  private static void logEvent(Context context, AppEvent event, AccessTokenAppIdPair accessTokenAppId)
  {
    if (shouldSuppressEvent(event))
    {
      return;
    }

    SessionEventsState state = getSessionEventsState(context, accessTokenAppId);
    state.addEvent(event);

    flushIfNecessary();
  }

  // This will also update the timestamp based on specified behavior.
  private static boolean shouldSuppressEvent(AppEvent event)
  {
    EventSuppression suppressionInfo = mapEventNameToSuppress.get(event.getName());
    if (suppressionInfo == null)
    {
      return false;
    }

    Date timestamp = mapEventsToSuppressionTime.get(event.getName());
    boolean suppressed;
    if (timestamp == null)
    {
      suppressed = false;
    }
    else
    {
      long delta = new Date().getTime() - timestamp.getTime();
      suppressed = delta < (suppressionInfo.getTimeoutPeriod() * 1000);
    }

    // Update the time if we're not suppressed, OR if we are suppressed but the behavior is to reset even on
    // suppressed events.
    if (!suppressed ||
        suppressionInfo.getBehavior() == SuppressionTimeoutBehavior.RESET_TIMEOUT_WHEN_LOG_ATTEMPTED)
    {
      mapEventsToSuppressionTime.put(event.getName(), new Date());
    }

    return suppressed;
  }

  static void eagerFlush()
  {
    if (getFlushBehavior() != FlushBehavior.EXPLICIT_ONLY)
    {
      flush(FlushReason.EAGER_FLUSHING_EVENT);
    }
  }

  private static void flushIfNecessary()
  {
    synchronized (staticLock)
    {
      if (getFlushBehavior() != FlushBehavior.EXPLICIT_ONLY)
      {
        if (getAccumulatedEventCount() > NUM_LOG_EVENTS_TO_TRY_TO_FLUSH_AFTER)
        {
          flush(FlushReason.EVENT_THRESHOLD);
        }
      }
    }
  }

  private static int getAccumulatedEventCount()
  {
    synchronized (staticLock)
    {

      int result = 0;
      for (SessionEventsState state : stateMap.values())
      {
        result += state.getAccumulatedEventCount();
      }
      return result;
    }
  }

  // Creates a new SessionEventsState if not already in the map.
  private static SessionEventsState getSessionEventsState(Context context, AccessTokenAppIdPair accessTokenAppId)
  {
    synchronized (staticLock)
    {
      SessionEventsState state = stateMap.get(accessTokenAppId);
      if (state == null)
      {
        // Retrieve attributionId, but we will only send it if attribution is supported for the app.
        AttributionIdentifiers attributionIdentifiers =
            AttributionIdentifiers.getAttributionIdentifiers(context);

        state = new SessionEventsState(attributionIdentifiers, context.getPackageName(), hashedDeviceAndAppId);
        stateMap.put(accessTokenAppId, state);
      }
      return state;
    }
  }

  private static SessionEventsState getSessionEventsState(AccessTokenAppIdPair accessTokenAppId)
  {
    synchronized (staticLock)
    {
      return stateMap.get(accessTokenAppId);
    }
  }

  private static void flush(final FlushReason reason)
  {

    Settings.getExecutor().execute(new Runnable()
    {
      @Override
      public void run()
      {
        flushAndWait(reason);
      }
    });
  }

  private static void flushAndWait(final FlushReason reason)
  {

    Set<AccessTokenAppIdPair> keysToFlush;
    synchronized (staticLock)
    {
      if (requestInFlight)
      {
        return;
      }
      requestInFlight = true;
      keysToFlush = new HashSet<AccessTokenAppIdPair>(stateMap.keySet());
    }

    accumulatePersistedEvents();

    FlushStatistics flushResults = null;
    try
    {
      flushResults = buildAndExecuteRequests(reason, keysToFlush);
    }
    catch (Exception e)
    {
      Log.d(TAG, "Caught unexpected exception while flushing: " + e.toString());
    }

    synchronized (staticLock)
    {
      requestInFlight = false;
    }

    if (flushResults != null)
    {
      final Intent intent = new Intent(ACTION_APP_EVENTS_FLUSHED);
      intent.putExtra(APP_EVENTS_EXTRA_NUM_EVENTS_FLUSHED, flushResults.numEvents);
      intent.putExtra(APP_EVENTS_EXTRA_FLUSH_RESULT, flushResults.result);
      LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent);
    }
  }

  private static FlushStatistics buildAndExecuteRequests(FlushReason reason, Set<AccessTokenAppIdPair> keysToFlush)
  {
    FlushStatistics flushResults = new FlushStatistics();

    boolean limitEventUsage = Settings.getLimitEventAndDataUsage(applicationContext);

    List<Request> requestsToExecute = new ArrayList<Request>();
    for (AccessTokenAppIdPair accessTokenAppId : keysToFlush)
    {
      SessionEventsState sessionEventsState = getSessionEventsState(accessTokenAppId);
      if (sessionEventsState == null)
      {
        continue;
      }

      Request request = buildRequestForSession(accessTokenAppId, sessionEventsState, limitEventUsage,
          flushResults);
      if (request != null)
      {
        requestsToExecute.add(request);
      }
    }

    if (requestsToExecute.size() > 0)
    {
      Logger.log(LoggingBehavior.APP_EVENTS, TAG, "Flushing %d events due to %s.",
          flushResults.numEvents,
          reason.toString());

      for (Request request : requestsToExecute)
      {
        // Execute the request synchronously. Callbacks will take care of handling errors and updating
        // our final overall result.
        request.executeAndWait();
      }
      return flushResults;
    }

    return null;
  }

  private static class FlushStatistics
  {
    public int numEvents = 0;
    public FlushResult result = FlushResult.SUCCESS;
  }

  private static Request buildRequestForSession(final AccessTokenAppIdPair accessTokenAppId,
                                                final SessionEventsState sessionEventsState, final boolean limitEventUsage,
                                                final FlushStatistics flushState)
  {
    String applicationId = accessTokenAppId.getApplicationId();

    Utility.FetchedAppSettings fetchedAppSettings = Utility.queryAppSettings(applicationId, false);

    final Request postRequest = Request.newPostRequest(
        null,
        String.format("%s/activities", applicationId),
        null,
        null);

    Bundle requestParameters = postRequest.getParameters();
    if (requestParameters == null)
    {
      requestParameters = new Bundle();
    }
    requestParameters.putString("access_token", accessTokenAppId.getAccessToken());
    postRequest.setParameters(requestParameters);

    int numEvents = sessionEventsState.populateRequest(postRequest, fetchedAppSettings.supportsImplicitLogging(),
        fetchedAppSettings.supportsAttribution(), limitEventUsage);
    if (numEvents == 0)
    {
      return null;
    }

    flushState.numEvents += numEvents;

    postRequest.setCallback(new Request.Callback()
    {
      @Override
      public void onCompleted(Response response)
      {
        handleResponse(accessTokenAppId, postRequest, response, sessionEventsState, flushState);
      }
    });

    return postRequest;
  }

  private static void handleResponse(AccessTokenAppIdPair accessTokenAppId, Request request, Response response,
                                     SessionEventsState sessionEventsState, FlushStatistics flushState)
  {
    FacebookRequestError error = response.getError();
    String resultDescription = "Success";

    FlushResult flushResult = FlushResult.SUCCESS;

    if (error != null)
    {
      final int NO_CONNECTIVITY_ERROR_CODE = -1;
      if (error.getErrorCode() == NO_CONNECTIVITY_ERROR_CODE)
      {
        resultDescription = "Failed: No Connectivity";
        flushResult = FlushResult.NO_CONNECTIVITY;
      }
      else
      {
        resultDescription = String.format("Failed:\n  Response: %s\n  Error %s",
            response.toString(),
            error.toString());
        flushResult = FlushResult.SERVER_ERROR;
      }
    }

    if (Settings.isLoggingBehaviorEnabled(LoggingBehavior.APP_EVENTS))
    {
      String eventsJsonString = (String) request.getTag();
      String prettyPrintedEvents;

      try
      {
        JSONArray jsonArray = new JSONArray(eventsJsonString);
        prettyPrintedEvents = jsonArray.toString(2);
      }
      catch (JSONException exc)
      {
        prettyPrintedEvents = "<Can't encode events for debug logging>";
      }

      Logger.log(LoggingBehavior.APP_EVENTS, TAG,
          "Flush completed\nParams: %s\n  Result: %s\n  Events JSON: %s",
          request.getGraphObject().toString(),
          resultDescription,
          prettyPrintedEvents);
    }

    sessionEventsState.clearInFlightAndStats(error != null);

    if (flushResult == FlushResult.NO_CONNECTIVITY)
    {
      // We may call this for multiple requests in a batch, which is slightly inefficient since in principle
      // we could call it once for all failed requests, but the impact is likely to be minimal.
      // We don't call this for other server errors, because if an event failed because it was malformed, etc.,
      // continually retrying it will cause subsequent events to not be logged either.
      PersistedEvents.persistEvents(applicationContext, accessTokenAppId, sessionEventsState);
    }

    if (flushResult != FlushResult.SUCCESS)
    {
      // We assume that connectivity issues are more significant to report than server issues.
      if (flushState.result != FlushResult.NO_CONNECTIVITY)
      {
        flushState.result = flushResult;
      }
    }
  }

  private static int accumulatePersistedEvents()
  {
    PersistedEvents persistedEvents = PersistedEvents.readAndClearStore(applicationContext);

    int result = 0;
    for (AccessTokenAppIdPair accessTokenAppId : persistedEvents.keySet())
    {
      SessionEventsState sessionEventsState = getSessionEventsState(applicationContext, accessTokenAppId);

      List<AppEvent> events = persistedEvents.getEvents(accessTokenAppId);
      sessionEventsState.accumulatePersistedEvents(events);
      result += events.size();
    }

    return result;
  }

  /**
   * Invoke this method, rather than throwing an Exception, for situations where user/server input might reasonably
   * cause this to occur, and thus don't want an exception thrown at production time, but do want logging
   * notification.
   */
  private static void notifyDeveloperError(String message)
  {
    Logger.log(LoggingBehavior.DEVELOPER_ERRORS, "AppEvents", message);
  }


  //
  // Deprecated Stuff
  //


  static class SessionEventsState
  {
    private List<AppEvent> accumulatedEvents = new ArrayList<AppEvent>();
    private List<AppEvent> inFlightEvents = new ArrayList<AppEvent>();
    private int numSkippedEventsDueToFullBuffer;
    private AttributionIdentifiers attributionIdentifiers;
    private String packageName;
    private String hashedDeviceAndAppId;

    public static final String EVENT_COUNT_KEY = "event_count";
    public static final String ENCODED_EVENTS_KEY = "encoded_events";
    public static final String NUM_SKIPPED_KEY = "num_skipped";

    private final int MAX_ACCUMULATED_LOG_EVENTS = 1000;

    public SessionEventsState(AttributionIdentifiers identifiers, String packageName, String hashedDeviceAndAppId)
    {
      this.attributionIdentifiers = identifiers;
      this.packageName = packageName;
      this.hashedDeviceAndAppId = hashedDeviceAndAppId;
    }

    // Synchronize here and in other methods on this class, because could be coming in from different
    // AppEventsLoggers on different threads pointing at the same session.
    public synchronized void addEvent(AppEvent event)
    {
      if (accumulatedEvents.size() + inFlightEvents.size() >= MAX_ACCUMULATED_LOG_EVENTS)
      {
        numSkippedEventsDueToFullBuffer++;
      }
      else
      {
        accumulatedEvents.add(event);
      }
    }

    public synchronized int getAccumulatedEventCount()
    {
      return accumulatedEvents.size();
    }

    public synchronized void clearInFlightAndStats(boolean moveToAccumulated)
    {
      if (moveToAccumulated)
      {
        accumulatedEvents.addAll(inFlightEvents);
      }
      inFlightEvents.clear();
      numSkippedEventsDueToFullBuffer = 0;
    }

    public int populateRequest(Request request, boolean includeImplicitEvents,
                               boolean includeAttribution, boolean limitEventUsage)
    {

      int numSkipped;
      JSONArray jsonArray;
      synchronized (this)
      {
        numSkipped = numSkippedEventsDueToFullBuffer;

        // move all accumulated events to inFlight.
        inFlightEvents.addAll(accumulatedEvents);
        accumulatedEvents.clear();

        jsonArray = new JSONArray();
        for (AppEvent event : inFlightEvents)
        {
          if (includeImplicitEvents || !event.getIsImplicit())
          {
            jsonArray.put(event.getJSONObject());
          }
        }

        if (jsonArray.length() == 0)
        {
          return 0;
        }
      }

      populateRequest(request, numSkipped, jsonArray, includeAttribution, limitEventUsage);
      return jsonArray.length();
    }

    public synchronized List<AppEvent> getEventsToPersist()
    {
      // We will only persist accumulated events, not ones currently in-flight. This means if an in-flight
      // request fails, those requests will not be persisted and thus might be lost if the process terminates
      // while the flush is in progress.
      List<AppEvent> result = accumulatedEvents;
      accumulatedEvents = new ArrayList<AppEvent>();
      return result;
    }

    public synchronized void accumulatePersistedEvents(List<AppEvent> events)
    {
      // We won't skip events due to a full buffer, since we already accumulated them once and persisted
      // them. But they will count against the buffer size when further events are accumulated.
      accumulatedEvents.addAll(events);
    }

    private void populateRequest(Request request, int numSkipped, JSONArray events, boolean includeAttribution,
                                 boolean limitEventUsage)
    {
      GraphObject publishParams = GraphObject.Factory.create();
      publishParams.setProperty("event", "CUSTOM_APP_EVENTS");

      if (numSkippedEventsDueToFullBuffer > 0)
      {
        publishParams.setProperty("num_skipped_events", numSkipped);
      }

      if (includeAttribution)
      {
        Utility.setAppEventAttributionParameters(publishParams, attributionIdentifiers,
            hashedDeviceAndAppId, limitEventUsage);
      }

      publishParams.setProperty("application_package_name", packageName);

      request.setGraphObject(publishParams);

      Bundle requestParameters = request.getParameters();
      if (requestParameters == null)
      {
        requestParameters = new Bundle();
      }

      String jsonString = events.toString();
      if (jsonString != null)
      {
        requestParameters.putByteArray("custom_events_file", getStringAsByteArray(jsonString));
        request.setTag(jsonString);
      }
      request.setParameters(requestParameters);
    }

    private byte[] getStringAsByteArray(String jsonString)
    {
      byte[] jsonUtf8 = null;
      try
      {
        jsonUtf8 = jsonString.getBytes("UTF-8");
      }
      catch (UnsupportedEncodingException e)
      {
        // shouldn't happen, but just in case:
        Utility.logd("Encoding exception: ", e);
      }
      return jsonUtf8;
    }
  }

  static class AppEvent implements Serializable
  {
    private static final long serialVersionUID = 1L;

    private JSONObject jsonObject;
    private boolean isImplicit;
    private static final HashSet<String> validatedIdentifiers = new HashSet<String>();
    private String name;

    public AppEvent(String eventName, Double valueToSum, Bundle parameters, boolean isImplicitlyLogged)
    {

      validateIdentifier(eventName);

      this.name = eventName;

      isImplicit = isImplicitlyLogged;
      jsonObject = new JSONObject();

      try
      {

        jsonObject.put("_eventName", eventName);
        jsonObject.put("_logTime", System.currentTimeMillis() / 1000);

        if (valueToSum != null)
        {
          jsonObject.put("_valueToSum", valueToSum.doubleValue());
        }

        if (isImplicit)
        {
          jsonObject.put("_implicitlyLogged", "1");
        }

        String appVersion = Settings.getAppVersion();
        if (appVersion != null)
        {
          jsonObject.put("_appVersion", appVersion);
        }

        if (parameters != null)
        {
          for (String key : parameters.keySet())
          {

            validateIdentifier(key);

            Object value = parameters.get(key);
            if (!(value instanceof String) && !(value instanceof Number))
            {
              throw new FacebookException(
                  String.format("Parameter value '%s' for key '%s' should be a string or a numeric type.",
                      value, key));
            }

            jsonObject.put(key, value.toString());
          }
        }

        if (!isImplicit)
        {
          Logger.log(LoggingBehavior.APP_EVENTS, "AppEvents",
              "Created app event '%s'", jsonObject.toString());
        }
      }
      catch (JSONException jsonException)
      {

        // If any of the above failed, just consider this an illegal event.
        Logger.log(LoggingBehavior.APP_EVENTS, "AppEvents",
            "JSON encoding for app event failed: '%s'", jsonException.toString());
        jsonObject = null;

      }
    }

    public String getName()
    {
      return name;
    }

    private AppEvent(String jsonString, boolean isImplicit) throws JSONException
    {
      jsonObject = new JSONObject(jsonString);
      this.isImplicit = isImplicit;
    }

    public boolean getIsImplicit()
    {
      return isImplicit;
    }

    public JSONObject getJSONObject()
    {
      return jsonObject;
    }

    // throw exception if not valid.
    private void validateIdentifier(String identifier)
    {

      // Identifier should be 40 chars or less, and only have 0-9A-Za-z, underscore, hyphen, and space (but no
      // hyphen or space in the first position).
      final String regex = "^[0-9a-zA-Z_]+[0-9a-zA-Z _-]*$";

      final int MAX_IDENTIFIER_LENGTH = 40;
      if (identifier == null || identifier.length() == 0 || identifier.length() > MAX_IDENTIFIER_LENGTH)
      {
        if (identifier == null)
        {
          identifier = "<None Provided>";
        }
        throw new FacebookException(
            String.format("Identifier '%s' must be less than %d characters", identifier, MAX_IDENTIFIER_LENGTH));
      }

      boolean alreadyValidated = false;
      synchronized (validatedIdentifiers)
      {
        alreadyValidated = validatedIdentifiers.contains(identifier);
      }

      if (!alreadyValidated)
      {
        if (identifier.matches(regex))
        {
          synchronized (validatedIdentifiers)
          {
            validatedIdentifiers.add(identifier);
          }
        }
        else
        {
          throw new FacebookException(
              String.format("Skipping event named '%s' due to illegal name - must be under 40 chars " +
                      "and alphanumeric, _, - or space, and not start with a space or hyphen.",
                  identifier));
        }
      }

    }

    private static class SerializationProxyV1 implements Serializable
    {
      private static final long serialVersionUID = -2488473066578201069L;
      private final String jsonString;
      private final boolean isImplicit;

      private SerializationProxyV1(String jsonString, boolean isImplicit)
      {
        this.jsonString = jsonString;
        this.isImplicit = isImplicit;
      }

      private Object readResolve() throws JSONException
      {
        return new AppEvent(jsonString, isImplicit);
      }
    }

    private Object writeReplace()
    {
      return new SerializationProxyV1(jsonObject.toString(), isImplicit);
    }

    @Override
    public String toString()
    {
      return String.format("\"%s\", implicit: %b, json: %s", jsonObject.optString("_eventName"),
          isImplicit, jsonObject.toString());
    }
  }

  // Read/write operations are thread-safe/atomic across all instances of PersistedEvents, but modifications
  // to any individual instance are not thread-safe.
  static class PersistedEvents
  {
    static final String PERSISTED_EVENTS_FILENAME = "AppEventsLogger.persistedevents";

    private static Object staticLock = new Object();

    private Context context;
    private HashMap<AccessTokenAppIdPair, List<AppEvent>> persistedEvents =
        new HashMap<AccessTokenAppIdPair, List<AppEvent>>();

    private PersistedEvents(Context context)
    {
      this.context = context;
    }

    public static PersistedEvents readAndClearStore(Context context)
    {
      synchronized (staticLock)
      {
        PersistedEvents persistedEvents = new PersistedEvents(context);

        persistedEvents.readAndClearStore();

        return persistedEvents;
      }
    }

    public static void persistEvents(Context context, AccessTokenAppIdPair accessTokenAppId,
                                     SessionEventsState eventsToPersist)
    {
      Map<AccessTokenAppIdPair, SessionEventsState> map = new HashMap<AccessTokenAppIdPair, SessionEventsState>();
      map.put(accessTokenAppId, eventsToPersist);
      persistEvents(context, map);
    }

    public static void persistEvents(Context context,
                                     Map<AccessTokenAppIdPair, SessionEventsState> eventsToPersist)
    {
      synchronized (staticLock)
      {
        // Note that we don't track which instance of AppEventsLogger added a particular event to
        // SessionEventsState; when a particular Context is being destroyed, we'll persist all accumulated
        // events. More sophisticated tracking could be done to try to reduce unnecessary persisting of events,
        // but the overall number of events is not expected to be large.
        PersistedEvents persistedEvents = readAndClearStore(context);

        for (Map.Entry<AccessTokenAppIdPair, SessionEventsState> entry : eventsToPersist.entrySet())
        {
          List<AppEvent> events = entry.getValue().getEventsToPersist();
          if (events.size() == 0)
          {
            continue;
          }

          persistedEvents.addEvents(entry.getKey(), events);
        }

        persistedEvents.write();
      }
    }

    public Set<AccessTokenAppIdPair> keySet()
    {
      return persistedEvents.keySet();
    }

    public List<AppEvent> getEvents(AccessTokenAppIdPair accessTokenAppId)
    {
      return persistedEvents.get(accessTokenAppId);
    }

    private void write()
    {
      ObjectOutputStream oos = null;
      try
      {
        oos = new ObjectOutputStream(
            new BufferedOutputStream(context.openFileOutput(PERSISTED_EVENTS_FILENAME, 0)));
        oos.writeObject(persistedEvents);
      }
      catch (Exception e)
      {
        Log.d(TAG, "Got unexpected exception: " + e.toString());
      }
      finally
      {
        Utility.closeQuietly(oos);
      }
    }

    private void readAndClearStore()
    {
      ObjectInputStream ois = null;
      try
      {
        ois = new ObjectInputStream(
            new BufferedInputStream(context.openFileInput(PERSISTED_EVENTS_FILENAME)));

        @SuppressWarnings("unchecked")
        HashMap<AccessTokenAppIdPair, List<AppEvent>> obj =
            (HashMap<AccessTokenAppIdPair, List<AppEvent>>) ois.readObject();

        // Note: We delete the store before we store the events; this means we'd prefer to lose some
        // events in the case of exception rather than potentially log them twice.
        context.getFileStreamPath(PERSISTED_EVENTS_FILENAME).delete();
        persistedEvents = obj;
      }
      catch (FileNotFoundException e)
      {
        // Expected if we never persisted any events.
      }
      catch (Exception e)
      {
        Log.d(TAG, "Got unexpected exception: " + e.toString());
      }
      finally
      {
        Utility.closeQuietly(ois);
      }
    }

    public void addEvents(AccessTokenAppIdPair accessTokenAppId, List<AppEvent> eventsToPersist)
    {
      if (!persistedEvents.containsKey(accessTokenAppId))
      {
        persistedEvents.put(accessTokenAppId, new ArrayList<AppEvent>());
      }
      persistedEvents.get(accessTokenAppId).addAll(eventsToPersist);
    }
  }
}
